Skip to content

Every demo application's last_activity_at is boot time: the stamp hook overwrites the seeded value unconditionally - #74

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-65-seeded-activity
Sep 7, 2026
Merged

Every demo application's last_activity_at is boot time: the stamp hook overwrites the seeded value unconditionally#74
os-sam merged 3 commits into
mainfrom
claude/issue-65-seeded-activity

Conversation

@os-sam

@os-sam os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #65

ApplicationStampHook ended with input.last_activity_at = new Date().toISOString() outside every guard, on beforeInsert and beforeUpdate alike, so buildApplications' authored daysAgo(a.activityDaysAgo) never survived the write. Shape (1) from the card, with the update path guarded too — the card's suggested one-liner fixes boot 1 and leaves claimSeedOwnership to wipe the history on boot 2.

The rule this PR chose, and where it is written down

Full reasoning is in the hook's header, section "What counts as activity on an application". Three clauses:

  1. An authored value wins, on both events — seed history, an import, a backfill. This is what carries the demo's history through the re-boot upsert. Tested with == null, so a client that sends the key as null still gets a stamp.
  2. Filing is activity — an insert with no authored value stamps now, the shape applied_at already had.
  3. An update stamps only when its payload names a field of the application itself. Not activity: owner_id (plugin-security's boot-time claim), days_to_offer and interview_count (the object's two non-authorable derived columns — one stamped by ats_offer_time_to_offer, one a Field.summary roll-up), and the engine's own id / created_at / created_by / updated_at / updated_by.

A deny-list, not an allow-list: a field added to the object tomorrow counts as activity without anyone remembering to list it, and the failure direction is one stamp too many rather than a timeline frozen at boot.

Why the two derived columns are exempt — measured, not assumed

Instrumenting the handler to log its payload keys on a seeded boot shows exactly three update shapes reaching ats_application, and not one of them is a person acting on the row:

     200 ["owner_id","updated_at"]              claimSeedOwnership, after the seed
      28 ["id","interview_count","updated_at"]  summary roll-up, one per interview-bearing application
      23 ["id","days_to_offer","updated_at"]    ats_offer_time_to_offer

Both derived writes restate a fact whose own timestamp lives on the other row (the offer's created_at, the interview's scheduled_at), and the recruiter's act that produced it — moving the application to stage: offer / stage: interview — is a payload that does name a field of the application and does stamp. Counting them would re-date those 23 + 28 rows to boot time on boot 1 and not on the next (both parent rows are inserted once and upserted after): a demo dataset that changes shape per boot. The now-stale paragraph in OfferTimeToOfferHook's header, which called that refresh "the right answer rather than a side effect to suppress", is corrected in the same file.

Acceptance 2 — the distribution, not one row

Boot with the demo seed on a persistent sqlite file, wait for [Seeder] Seed loading complete, then read ats_application straight out of the file.

Before (origin/main = 54cd69c):

rows=200
last_activity_at count/min/max: (200, "2026-09-07T19:03:17.128Z", "2026-09-07T19:03:17.128Z")
distinct calendar days: 1
last_activity_at by calendar day:
  [["2026-09-07",200]]
applied_at distinct calendar days: 49

All 200 identical to the millisecond — not a spread — because the boot's last write to these rows is the multi: true ownership claim, whose single SET clause carries the last matched row's value to every matched row. (#65 read the same table as a 0.7s window on its author's boot; either way the column is boot time and either way the producing write is on the update path.)

After (this branch):

rows=200
last_activity_at count/min/max: (200, "2026-07-30T00:00:00.000Z", "2026-09-06T00:00:00.000Z")
distinct calendar days: 28
last_activity_at by calendar day:
  [["2026-07-30",1],["2026-08-04",2],["2026-08-05",2],["2026-08-06",2],["2026-08-12",1],["2026-08-14",3],
   ["2026-08-16",3],["2026-08-17",2],["2026-08-18",2],["2026-08-19",3],["2026-08-20",8],["2026-08-21",4],
   ["2026-08-22",7],["2026-08-23",9],["2026-08-24",8],["2026-08-25",5],["2026-08-26",14],["2026-08-27",11],
   ["2026-08-28",11],["2026-08-29",12],["2026-08-30",13],["2026-08-31",11],["2026-09-01",13],["2026-09-02",12],
   ["2026-09-03",7],["2026-09-04",12],["2026-09-05",10],["2026-09-06",12]]
invariant last_activity_at >= applied_at: 200/200 hold, 0 violations
rows with days_to_offer set: 23 — their last_activity_at days are seeded dates, not boot time

It is the seed's spread, not merely a spread. The expected distribution was computed independently from APPLICATIONS[i].activityDaysAgo in src/data/shared/pipeline.ts against the boot day and compared to the values read out of sqlite:

EXPECTED (seed activityDaysAgo, boot day 2026-09-07): [["2026-07-30",1],["2026-08-04",2], … ,["2026-09-06",12]]
MEASURED (after-fix boot 1, read from sqlite):        [["2026-07-30",1],["2026-08-04",2], … ,["2026-09-06",12]]
IDENTICAL: true

The last_activity_at >= applied_at invariant holds in the database for all 200 rows (and the seed itself authors 0 rows with activityDaysAgo > appliedDaysAgo, so the two agree).

Acceptance 3 — the two-boot test on one sqlite file

Same file, server fully stopped between boots, values dumped after platform bootstrap complete each time.

boot 1 boot 2
seeder {"inserted":818,"updated":0,"skipped":0,"errored":0} {"inserted":0,"updated":7,"skipped":811,"errored":0}
security bootstrap {"seeded":8,"adminPromoted":true,"ownershipClaimed":544,…} {"seeded":8,"adminPromoted":false,"reason":"already_have_admin",…}
last_activity_at 28 calendar days, 2026-07-30 … 2026-09-06 identical
rows compared: 200 | identical last_activity_at: 200 | changed: 0
full 200-value multiset identical: true
boot-2 rows at boot time (non-midnight): 0
applied_at identical across boots: true

The same two boots on origin/main, for contrast — this is the failure the insert-only guard would have shipped:

BEFORE FIX two-boot: rows 200 | unchanged: 0 | changed: 200
  boot1 distinct values: 1   boot2 distinct values: 200
  boot1 sample: 2026-09-07T19:03:17.128Z -> boot2: 2026-09-07T19:04:12.110Z
  boot 2 seeder line: {"inserted":0,"updated":207,"skipped":611,"errored":0}

Note the seeder line changes shape with the fix: 207 updated before (every application drifted from its authored row and was rewritten), 7 after (the applications now match the seed, so they are skipped). The fix makes the demo seed idempotent.

Acceptance 4 — a real update still stamps, a bookkeeping payload does not

Signed in as admin@quillstone.example (employer administrator) with an Origin header; GET /api/v1/data/ats_application answers total: 27, hasMore: false, records: 27 (this employer's own rows).

PATCH payload last_activity_at after updated_at after
— (row as seeded) 2026-08-22T00:00:00.000Z 2026-09-07T19:11:53.369Z
{"rating":4} 2026-09-07T19:13:34.358Z (now) 2026-09-07T19:13:34.359Z
{"stage":"screening"} 2026-09-07T19:13:50.563Z (now) 2026-09-07T19:13:50.564Z
{"last_activity_at":"2026-08-22T00:00:00.000Z"} 2026-08-22T00:00:00.000Z (the caller's value, clause 1) 2026-09-07T19:13:50.751Z
{"owner_id":"usr_ats_0_owner"} 2026-08-22T00:00:00.000Z (unchanged) 2026-09-07T19:14:07.033Z

The last row is the claim's payload shape done by hand: the write happened (updated_at moved), the activity clock did not. At boot scale that same shape is the 200-row claim pass, and the two derived shapes above are the other 51 non-activity dispatches.

Acceptance 5 — ripple check, measured

  • Views. last_activity_at appears in exactly one view definition — the seeker's mine timeline in src/views/application.view.ts, as a column. Every application view sorts on const sort = [{ field: 'applied_at', order: 'desc' }], and the timeline's startDateField is applied_at. No view's row ORDER changes; the one thing that changes is the value shown in that column, which is the point of the card.
  • Dashboards / datasets. No dashboard, tile or dataset reads the column (grep -rln last_activity src/ hits only the object, the hook, the view, the seed builder, F4's comment and the two translation bundles). The "Applications Awaiting Action" KPI everyone suspects is filter: { stage: { $in: ['applied','screening'] } } — stage, not recency.
  • Funnel, from the seeded database after the fix: applied 88 · screening 46 · interview 28 · offer 14 · hired 9, plus rejected 15 · withdrawn 0, over the seed's 818 rows. Unmoved.
  • Screenshots. No shot in docs/screenshots/ photographs a surface that renders this column: 01/02 are dashboards, 03 a dashboard, 04/07 the kanban, 05 the interview calendar, 06 the job grid. The seeker "My Applications" timeline — the only place the column is rendered — is not among them. No screenshot moves; none retaken.

Acceptance 6 — the sibling objects

Only two other now-stamps exist in src/hooks/, and both are already guarded: ats_inquiry.submitted_at (if (inserting && input.submitted_at == null)) and ats_inquiry.converted_at (written only on the update that moves status to converted). ats_interview, ats_offer, ats_candidate and ats_candidate_credential have no timestamp assignment at all — their stamps write display_name / employer / employer_org / candidate_user, each behind inserting || touched([...]). Nothing else to fix in this PR.

Confirmed in the database rather than only by reading, on the same seeded boot — every sibling's seeded datetime column keeps its authored spread:

ats_interview            scheduled_at 14 days   2026-09-08 … 2026-09-21
ats_offer                start_date   22 days   2026-08-19 … 2026-11-06 | expires_at 17 days
ats_inquiry              submitted_at  6 days   2026-09-01 … 2026-09-06
ats_candidate_credential issued_at    30 days   2023-06-05 … 2026-08-08
ats_job                  published_at 29 days   2026-06-19 … 2026-08-31

(ats_offer.created_at reads boot time on all 23 rows — that is the engine's own audit column, not a seeded one.)

Gates

Each exit code captured before any pipe.

VALIDATE_EXIT=0     ✓ Validation passed (770ms)
LINT_EXIT=0         ✓ 7 assertions — the comparator still reports drift, orphans and gaps
                    ✓ every bundle key restates the label its metadata declares
                      (bundle keys 512 · source labels 512 · compared 512)
TYPECHECK_EXIT=0    tsc --noEmit, no output

pnpm lint's three approval-approvers-may-resolve-empty suggestions are pre-existing on origin/main and untouched here.

The handler stays lowerable to a metadata-only body — Skipping legacy runtime bundle (all 9 callables are body-only), and the guard is present in the artifact with the inferred capabilities: ["api.read"]:

bookkeeping = ["id", "owner_id", "created_at", "created_by", "updated_at", "updated_by", "days_to_offer", "interview_count"];
const activity = Object.keys(input).some((k) => input[k] !== void 0 && !bookkeeping.includes(k));
if (inserting || activity) input.last_activity_at = ...

Scope

One file, src/hooks/stamp.hook.ts. No seed change was needed — the seed already authors activityDaysAgo per row, and this branch proves the value now arrives intact. src/security/, src/objects/, src/views/, src/apps/, src/dashboards/, src/data/ and src/flows/ are untouched.

Two out-of-scope findings were filed rather than ridden along:


Generated by Claude Code

`ApplicationStampHook` assigned `last_activity_at = now()` outside every
guard, on `beforeInsert` and `beforeUpdate` alike, so the seed's authored
`activityDaysAgo` never reached the database: all 200 demo applications
read one identical boot-time instant — one instant, not a spread, because
`claimSeedOwnership`'s `multi: true` claim applies a single SET clause to
every matched row.

Guard the assignment on both events. An authored value wins; a filing is
activity; an update is activity when its payload names a field of the
application rather than one of the platform's own bookkeeping columns
(`owner_id`, the engine's audit columns) or the `days_to_offer` metric a
sibling hook stamps. The chosen rule and every exception is written down
in the hook's header, and the stale paragraph in `OfferTimeToOfferHook`
that described the unconditional refresh is corrected there.

Guarding only the insert would have been a boot-1 fix: the ownership claim
and the seed's own re-boot upsert wipe restored history on boot 2 of a
persistent database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
`interview_count` is a `Field.summary` the engine recomputes when an
interview row lands; measured on a seeded boot it reaches the update path
as `{id, interview_count, updated_at}` for the 28 interview-bearing
applications. Like `days_to_offer` it restates a fact whose timestamp
lives on the other row, and counting it re-dated those 28 rows to boot
time on boot 1 and not on the next — the same per-boot instability the
metric exemption avoids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The header now says what this branch measured — 200 identical values at the
end of boot 1, the claim pass as the boot's last write to the row, and the
200-of-200 change across two boots before the guard — and records that #65's
own read of the same table was a 0.7s window, which does not change either
conclusion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review — reproduced the whole thing on my own two boots. It holds.

Nine lines of logic, and the reasoning behind them is the part that matters, so I re-ran the acceptance rather than reading the tables.

The distribution is the seed's, checked against a parse the fix never touches

Booted on a persistent sqlite file, waited for the seeder and the security bootstrap, then read ats_application straight out of the file — and computed the expectation myself by regexing activityDaysAgo out of src/data/shared/pipeline.ts and subtracting from my boot day:

EXPECTED (200 rows parsed from the seed skeleton, boot day 2026-09-07):
  [('2026-07-30',1),('2026-08-04',2),('2026-08-05',2),('2026-08-06',2),('2026-08-12',1),('2026-08-14',3),
   ('2026-08-16',3),('2026-08-17',2),('2026-08-18',2),('2026-08-19',3),('2026-08-20',8),('2026-08-21',4),
   ('2026-08-22',7),('2026-08-23',9),('2026-08-24',8),('2026-08-25',5),('2026-08-26',14),('2026-08-27',11),
   ('2026-08-28',11),('2026-08-29',12),('2026-08-30',13),('2026-08-31',11),('2026-09-01',13),('2026-09-02',12),
   ('2026-09-03',7),('2026-09-04',12),('2026-09-05',10),('2026-09-06',12)]
MEASURED (read out of sqlite): identical
IDENTICAL: True   ·   200 / 200

28 calendar days where main has one. last_activity_at >= applied_at holds 200/200 in the database, and the funnel reads 88 / 46 / 28 / 14 / 9 (+15 rejected).

The two-boot test, on one file, stopped fully between

boot 1  seeder {"inserted":818,"updated":0,"skipped":0,"errored":0}
        security {"seeded":8,"adminPromoted":true,"ownershipClaimed":544}
boot 2  seeder {"inserted":0,"updated":7,"skipped":811,"errored":0}
        security {"seeded":8,"adminPromoted":false,"reason":"already_have_admin"}

identical last_activity_at across the two boots: 200 of 200
changed: 0
boot-2 rows carrying a boot-time (non-midnight) value: 0
distribution identical: True     funnel identical: True

This is the acceptance the card's own suggested one-liner would have failed, and it is the reason I wrote the dispatch the way I did. You went past the suggestion rather than implementing it — right call.

The guard is precise in both directions, not merely conservative

A guard that stops the wipe by never stamping would also freeze the timeline for real users, so I drove the matrix myself as admin@quillstone.example (27 rows, hasMore: false, asserted before counting):

PATCH payload last_activity_at updated_at moved verdict
(as seeded) 2026-09-06T00:00:00.000Z baseline
{"owner_id": …} unchanged yes PASS — bookkeeping, no stamp
{"days_to_offer": 99} unchanged yes PASS — derived, no stamp
{"rating": 4} → now yes PASS — real edit stamps
{"stage": "interview"} → now yes PASS — real edit stamps
{"last_activity_at": "2026-08-01…"} kept at 2026-08-01 yes PASS — authored value wins

updated_at moves in every row, so the writes all landed — the guard gates the activity clock, not the write.

Three judgements I want to endorse explicitly, because each could have gone the lazy way

One property worth naming, not a defect

Clause 1 makes last_activity_at caller-settable — my backdate to 2026-08-01 stuck. That is load-bearing: the seed is a caller, so the column cannot be readonly the way I made days_to_offer on #68 without breaking this fix. The field's declaration claims nothing to the contrary, so unlike that case there is no unenforced promise here — but "last activity" is writable by anyone who can edit the row, and that is now a deliberate property rather than an accident.

Not re-run

pnpm validate && pnpm lint && pnpm typecheck — CI ran exactly those three on 1e4c7cc and they are green. My time was better spent on the behaviour CI cannot see.

The side effect I like most: the seeder line goes from updated: 207 to updated: 7, because the applications now match their authored rows. The demo seed is idempotent as a consequence of this fix.

Merging. #72 and #73 are correctly filed rather than ridden along — #72 especially, since a header claiming seed rows bypass hooks via skipTriggers is exactly the false premise that would have stopped someone from finding this bug.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 7, 2026 19:31
@os-sam
os-sam merged commit e759ba5 into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every demo application's last_activity_at is boot time: the stamp hook overwrites the seeded value unconditionally

2 participants